Skip to content

Stdin - support everywhere sensible; usage error for stray - elsewhere - #641

Merged
jeremy merged 20 commits into
mainfrom
stdin
Aug 21, 2026
Merged

Stdin - support everywhere sensible; usage error for stray - elsewhere#641
jeremy merged 20 commits into
mainfrom
stdin

Conversation

@jeremy

@jeremy jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member

Agents instinctively pass - to mean "read content from stdin" — but only comments create/update honored it. Everywhere else the hyphen landed as literal content: a todo titled -, a message body of -.

Tier 1 — - reads stdin on every content input

Content-kind positionals: comments create/update, checkins answer create/update, todos create (join-all pattern); messages create [body], cards create [body], docs create [content], chat post/update, boost create, notes set (exact-positional pattern).

Content flags: --data (api post/put), --body (messages/cards update), --content (chat, files update), --description (todos, schedule, projects, todolists, templates, gauges, cards column, uploads create, upload, files replace), --comment (todos sweep), --file (notes set).

Resolution lives in internal/commands/stdin.go. The shared vocabulary — the allow_dash annotation and pipe detection — is the new internal/stdinarg leaf package, because internal/cli needs it too: --agent help now auto-synthesizes a per-command note ("Pass - to read from stdin: [body], --description") from the annotation, so tier-1 coverage self-documents, including for future commands.

Tier 2 — stray literal - + piped stdin = usage error

A central guard covers every runnable command in the assembled tree (~380 commands, aliases and future ones included). On subcommands it wraps the Args validator: cobra runs ValidateArgs after flag parsing (so Changed and ArgsLenAtDash are live) but before the persistent pre-run chain, PreRunE, and required-flag validation — so a stray - is rejected before any lifecycle side effect and before a competing usage error can shadow it. It is not a PersistentPreRunE hook across the tree because cobra runs only the innermost one, and the agent hook already shadows the root's.

The root is the one exception: its Args must stay nil for cobra's legacyArgs unknown-subcommand check, so its guard hangs off the front of its own PersistentPreRunE, acting only when the root itself executes. That is still ahead of config loading, profile resolution and --jq validation.

When stdin is piped and an exact - appears anywhere not annotated (positional or string/stringArray flag value), the command fails with a usage error naming the offender, pointing at where it does accept stdin, and teaching the -- escape. On a TTY, a literal - stays legal everywhere. Two allowed - in one invocation can never both be satisfied, so that errors regardless of pipe state. --out - (attachments/files download) is exempted as the stdout idiom. Cobra's generated meta commands — help and the completion commands — are exempt too: they perform no Basecamp content write, and completion legitimately receives - as the word being completed (basecamp todos create -<TAB> runs basecamp __complete todos create -), so guarding it would break flag completion across the CLI.

Behavior changes

  1. comments create 123 - extra — was a silent literal "- extra" comment, now a usage error.
  2. - with TTY stdin — was hang-until-Ctrl-D, now an immediate usage error teaching the escapes: pipe it, heredoc (… - <<'EOF'), cat | … - (type + Ctrl-D), or --edit where it exists.
  3. Bare-pipe auto-read removed from comments create and notes set — piped stdin without - now errors with a hint instead of being silently consumed. Corollary: an unclaimed pipe alongside a named source (generate | notes set --file x.md) is ignored rather than raising the old ambiguity error — pipes are only ever a source through an explicit -, uniformly.
  4. notes set - (piped) — was a bogus "two sources" error, now works; notes set --file - — was ENOENT on a file named -, now stdin.
  5. Piped scripts passing literal - as a title/name/path now error; -- is the documented escape. TTY usage unaffected.
  6. Stdin content gets trailing newlines trimmed — Markdown doesn't care, but titles and boost's 16-rune limit do (printf '🎉\n' | boost create <id> - no longer burns a rune).

Flag for review

No --stdin flag. A precedent survey settled on - as the universal content-from-stdin idiom; --stdin in the wild means other things (git plumbing = list-of-items, kubectl = attach container stdin), and heredoc/cat | give interactive humans the classic TTY path with zero new surface. This was an open question during planning — veto welcome if you still want the flag.

Tests

  • internal/stdinarg: annotation parsing, pipe detection (char-device TTY stand-in per the established edit_test.go seam).
  • Resolver semantics table + -- escape through real parses.
  • Guard: unlisted positional (projects create -), unlisted flag (todos update --title -), TTY passthrough, --out - exemption, double-dash rejection, --attach - alongside an allowed body, -- escape.
  • Per-pattern integration through mock transports: messages create body -, api post --data -, todos create -, boost create - (+ over-limit stdin), todos update --description -, notes set both forms.
  • New e2e/stdin_dash.bats: empty-pipe rejection, TTY no-hang, bare-pipe hint, tier-2 rejection with -- escape, -- passthrough — all pre-network, no cassette needed. (The planned "posts body against cassette" e2e isn't recordable without live credentials — the happypath cassette set is read-only — so wire-level posting is covered by the mock-transport integration tests instead.)

bin/ci green: fmt, vet, lint, unit, e2e, surface snapshot (no Use-string or flag renames, so no regen), skill drift, smoke coverage, provenance. SKILL.md's - idiom is generalized in the same PR — it previously over-promised; now it's true.


Summary by cubic

Adds uniform "-" (stdin) support across content inputs and installs a guard that errors on stray "-" when stdin is piped, preventing pipes from becoming literal content. Also fixes jq-backed output to avoid double writes on filter errors and classifies Cobra arity errors as usage.

  • Coverage: accepts "-" on content positionals (comments create/update; check-ins answer create/update; todos create; messages/cards/docs create body; chat post/update; boost create; notes set) and content flags (--data, --body, --content, --description, --comment, --file). --out - (download) keeps the stdout idiom. help and __complete are exempt; agent --help autolists stdin-capable inputs via stdinarg.
  • Guard and ordering: runs at Args-validation time, rejects two stdin consumers and empty stdin, and dedupes alias flags; the root is guarded in pre-run. Stdin is read only when "-" is present. TUIs (wizards/pickers) now require character‑device stdin and stdout. An AST backstop enforces pre-read validation, and it now catches requireNumericID sites.
  • Pre-read validations hoisted: parse/validate IDs before any read (chat line/room; boost/event; check-ins; schedules; explicitly supplied dock IDs; uploads --folder ID), reject foreign API hosts early, and validate attachment paths first. Files replace recording-type, cards update attachment validation, schedule timestamps, and todos --loose vs list are decided before reading. Messages/cards/docs create bound at MaximumNArgs(2). Chat post/update reject combining a positional message with --content.
  • Behavior changes:
    • Bare pipes are no longer auto-read; stdin is consumed only when "-" is supplied.
    • "comments create 123 - extra" — was a silent literal "- extra", now a usage error.
    • "-" with TTY stdin — was hang-until-EOF, now an immediate usage error with hints.
    • Stdin content trims trailing CRLF/LF.
  • Required actions:
    • Pass "-" wherever stdin should be consumed; the CLI never reads stdin implicitly.
    • For a literal "-" positional, use "--" to escape it. For flags with a literal "-", run without piped stdin.

Written for commit a6bcf29. Summary will update on new commits.

Review in cubic

Agents instinctively pass - to mean "read content from stdin", but only
comments create/update honored it — everywhere else the hyphen landed as
literal content (a todo titled "-", a message body of "-").

Tier 1: - now reads stdin on every content-kind positional (comments
create/update, checkins answer create/update, todos create, messages
create [body], cards create [body], docs create [content], chat
post/update, boost create, notes set) and content flag (--data on api
post/put, --body, --content, --description, --comment on todos sweep,
--file on notes set). Resolution lives in internal/commands/stdin.go;
the shared vocabulary (allow_dash annotation, pipe detection) in the new
internal/stdinarg leaf package, since internal/cli needs it too for
agent help.

Tier 2: everywhere else, a literal - combined with piped stdin is
ambiguous — the caller almost certainly meant the pipe — so a central
guard wrapped around every RunE in the tree rejects it with a usage
error naming the offender, pointing at where the command does accept
stdin, and teaching the -- escape for a literal hyphen. On a TTY,
literal - stays legal everywhere. --out - (attachments/files download)
is exempted as the stdout idiom.

Behavior changes:
- "comments create 123 - extra" — was a silent literal "- extra"
  comment, now a usage error.
- "-" with TTY stdin — was hang-until-Ctrl-D, now an immediate usage
  error teaching the escapes (pipe, heredoc, cat |, --edit where it
  exists). No new --stdin flag: - is the universal idiom, and --stdin
  in the wild means other things (git plumbing, kubectl).
- Bare-pipe auto-read removed from comments create and notes set: a
  pipe without - errors with a hint instead of being silently consumed.
  Pipes are only ever a source through an explicit -; an unclaimed pipe
  alongside a named source is ignored, the CLI-wide rule.
- "notes set -" (piped) — was a bogus two-source error, now works;
  "notes set --file -" — was ENOENT on a file named -, now stdin.
- Piped scripts passing literal - as a title/name/path now error; -- is
  the documented escape.
- Stdin content gets trailing newlines trimmed (Markdown doesn't care;
  titles and boost's 16-rune limit do).

Agent help auto-documents each command's stdin inputs from the
allow_dash annotation; SKILL.md generalizes the - idiom it previously
over-promised.
Copilot AI balanced review requested due to automatic review settings August 19, 2026 03:46
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Aug 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Standardizes explicit - stdin handling across content-bearing CLI commands and rejects ambiguous stray dashes.

Changes:

  • Adds shared stdin resolution and command-tree guard logic.
  • Enables stdin for supported positional arguments and flags.
  • Adds unit, integration, E2E, agent-help, and skill documentation updates.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
skills/basecamp/SKILL.md Documents stdin conventions.
internal/stdinarg/stdinarg.go Adds annotations and pipe detection.
internal/stdinarg/stdinarg_test.go Tests shared stdin utilities.
internal/commands/stdin.go Implements resolution and dash guard.
internal/commands/stdin_test.go Tests resolver behavior.
internal/commands/stdin_integration_test.go Tests request-level stdin handling.
internal/commands/dash_guard_test.go Tests central guard behavior.
internal/commands/api.go Supports stdin JSON bodies.
internal/commands/attachments.go Exempts stdout output syntax.
internal/commands/boost.go Supports positional stdin content.
internal/commands/cards.go Supports card content stdin.
internal/commands/chat.go Supports chat content stdin.
internal/commands/checkins.go Supports answer content stdin.
internal/commands/comment.go Makes comment stdin explicit.
internal/commands/comment_test.go Updates comment stdin tests.
internal/commands/commands_test.go Installs guard in test tree.
internal/commands/files.go Supports document/upload content stdin.
internal/commands/gauges.go Supports description stdin.
internal/commands/helpers.go Removes obsolete pipe reader.
internal/commands/messages.go Supports message body stdin.
internal/commands/notes.go Makes note stdin explicit.
internal/commands/notes_test.go Tests explicit note sources.
internal/commands/projects.go Supports description stdin.
internal/commands/schedule.go Supports schedule description stdin.
internal/commands/templates.go Supports template description stdin.
internal/commands/todolists.go Supports todolist description stdin.
internal/commands/todos.go Supports todo content and flag stdin.
internal/cli/root.go Installs guard and generates agent notes.
e2e/stdin_dash.bats Exercises CLI stdin behavior.
Suppressed comments (2)

internal/commands/stdin.go:203

  • The documented -- escape cannot preserve a literal - used as a flag value. For example, with piped stdin, todos update 1 --title - is rejected, but moving - after -- makes it positional rather than the value of --title; --title=- is still detected by this guard. Please either define a workable flag-value escape (and test it) or avoid rejecting flag values, rather than directing users to an impossible invocation.
		hint := `For a literal "-", pass it after the -- separator`

internal/commands/stdin.go:191

  • Changed alias flags that share one destination are double-counted from their final value. For example, templates update 1 --description text --desc - leaves both flag values reporting -, so this increments allowed twice and rejects the invocation even though only one dash was supplied. Count actual dash occurrences or model aliases as one input before enforcing the one-reader rule.
		if allow.Flag(f.Name) {
			allowed += dashes

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/commands/stdin.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b81a070f07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Four fixes to the dash guard's contract from review:

- Run the guard at Args-validation time instead of wrapping RunE. Cobra
  runs ValidateArgs after flag parsing (Changed and ArgsLenAtDash are
  available) but before the persistent pre-run chain, PreRunE, and
  required-flag validation — so the stray-dash error fires before any
  lifecycle side effect (config hardening, the update check) and before
  a competing usage error can shadow it. The root command stays
  unwrapped: its nil Args is load-bearing — cobra's Find() rejects
  unknown subcommands (legacyArgs) only while Args == nil, and wrapping
  it turned "basecamp unknowncmd" into a quickstart run (caught by
  core.bats). Nothing is lost: root positionals are subcommand names,
  and a bare "basecamp -" runs quickstart, which posts no content.

- Dedupe alias flags by their shared pflag.Value. --description and
  --desc wrap one backing variable, and pflag hands both the same Value
  instance; counting each spelling separately made
  "--description old --desc -" a false "two stdin inputs" error.
  One logical value now counts once, in both flag orders.

- Stop advertising -- as the escape for flag values — it only escapes
  positionals. Positional offenders keep the -- hint; flag offenders
  get the honest remedy (run without piped stdin, append </dev/tty).
  SKILL.md updated to match.

- Trim trailing CRLF, not just LF, from stdin content: a Windows-style
  pipe left \r behind, counting a phantom rune against boost's
  16-rune limit.

Also replace the weak final e2e case (which contradicted the file's
no-network header by dialing localhost) with a deterministic local
success: config set ... -- - stores a literal "-", read back via
config show.
Copilot AI review requested due to automatic review settings August 19, 2026 05:17
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the advisory in cfb9959. Per finding:

1 (guard timing) — fixed. The guard now wraps each runnable command's Args validator instead of RunE: cobra runs ValidateArgs after flag parsing (so Changed/ArgsLenAtDash are live) but before the persistent pre-run chain, PreRunE, and required-flag validation, so the stray-dash error fires before any lifecycle side effect or competing usage error. One discovery en route: the root's nil Args is load-bearing — cobra's Find() applies legacyArgs (unknown-subcommand rejection) only while Args == nil, and wrapping the root turned basecamp unknowncmd into a successful quickstart run (core.bats caught it). The root stays unwrapped, losing nothing: its positionals are subcommand names, and a bare basecamp - runs quickstart, which posts no content. New test pins the ordering (guard beats ExactArgs, PreRunE, and MarkFlagRequired).

2 (alias false-positive) — fixed. pflag hands aliases sharing a backing variable the same Value instance, so the guard now dedupes on Value identity: --description old --desc - is one logical stdin input (reads stdin), and --desc - --description old resolves to the literal old. Both orders tested; covers schedule, templates, and every other alias pair for free.

3 (impossible escape) — fixed. --name=- can't be the explicit-literal form — the guard sees only the parsed value, and special-casing the = spelling would need re-scanning os.Args. So the hint is now honest per offender kind: -- is mentioned only for positional offenders; flag offenders get the real remedy, run without piped stdin (</dev/tty). SKILL.md matches.

4 (newlines) — CRLF fixed; the per-input trim policy declined. Trailing \r\n is now trimmed alongside \n (test: a 16-rune boost followed by CRLF passes). But I'm keeping the uniform trailing-newline trim rather than classifying inputs as body-like vs title-like: only trailing newlines are touched (interior breaks preserved, so chat's text/plain "line breaks preserved" promise holds), Markdown→HTML conversion makes trailing newlines invisible for every rich-text body, and a per-site trim knob across ~30 call sites buys correctness only for the case of a chat message whose trailing blank lines are deliberate — which a trailing newline in a pipe almost never is. The uniform rule is also what SKILL.md documents. Happy to revisit if a real case surfaces.

5 (weak e2e) — fixed. The final case is now a deterministic local success — printf 'x' | basecamp config set project_id --json -- - stores a literal -, read back via config show — and the file header's no-network claim is now true.

bin/ci green end to end after the changes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.

Suppressed comments (3)

internal/commands/messages.go:457

  • This new stdin path still accepts extra positionals silently. For example, printf body | basecamp messages create Title - unexpected reads stdin and posts the message while dropping unexpected, even though Use declares only <title> [body]. Add a maximum-argument validator so malformed stdin invocations fail instead of losing input.
			// Validate user input first, before checking account. The --edit
			// exclusion runs before "-" resolution so --edit … - errors
			// without consuming stdin.

internal/commands/cards.go:875

  • Extra positionals remain silently ignored on the new stdin path: printf body | basecamp cards create Title - unexpected consumes stdin but drops unexpected. Since this command declares exactly <title> [body], cap it at two arguments before resolving -.
				var err error
				content, err = resolveContentValue(cmd, args[1], 1, "[body]")
				if err != nil {
					return err

internal/commands/files.go:1252

  • The new - resolver only examines args[1], so printf body | basecamp docs create Title - unexpected succeeds and silently discards unexpected. Enforce the two positionals declared by Use before consuming stdin.
				var contentErr error
				content, contentErr = resolveContentValue(cmd, args[1], 1, "[content]")
				if contentErr != nil {
					return contentErr
				}

The TTY hint for a flag-borne "-" suggested a bare trailing "-", which
would exceed the command's positional arity — it now repeats the flag
("api post ... --data -").

messages/cards/docs create took unbounded positionals, so a stray third
token was silently dropped after "-" had already drained stdin. All
three now bound at MaximumNArgs(2), which runs before the read; the
other exact-positional consumers were already bounded.

Cobra's arity errors classified as api_error, telling agents to retry a
call that can never succeed. They are usage errors by construction.

Drop the concrete </dev/tty redirect from the literal-dash hint: it is
unusable on Windows and on headless runners with no controlling
terminal. The remedy stays, minus the platform-specific spelling.
Copilot AI review requested due to automatic review settings August 19, 2026 20:24
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round 2 addressed in b8f09d7. Both mediums accepted, the low-priority tightening taken, plus one adjacent fix the arity bound exposed.

1. TTY hints are invalid for flag-based stdin — fixed.

stdinEscapeHint now takes the what it was given and repeats the input that actually carried the -. Verified against your exact repro:

$ basecamp api post /foo --data - --json </dev/null
"hint": "Pipe the content (printf '...' | basecamp api post ... --data -), use a heredoc
         (basecamp api post ... --data - <<'EOF'), or run cat | basecamp api post ... --data -
         and type the content, ending with Ctrl-D"

Positionals are unchanged (... -). Covered by a unit test that asserts the flag spelling is present and that a bare ... -) is absent, plus an integration test driving api post --data - through a real Execute with a transport that fails the test if any request escapes. Closes r3809916059.

2. Exact-positional consumers discard extra arguments — fixed, and the class is closed.

MaximumNArgs(2) on messages create, cards create, docs create. I audited every positional resolveContentValue call site rather than just the three you named: boost create (ExactArgs(2)), chat post (MaximumNArgs(1)), chat update (MaximumNArgs(2)), notes set (MaximumNArgs(1)) were already bounded. Those three were the whole remainder.

Validation-before-consumption is proven, not asserted: the test wires stdin to a reader that records whether Read was ever called and the SDK to a transport that counts calls, then runs create Title - unexpected on all three and asserts the arity error, read == false, and zero requests. The ordering holds structurally too — cobra runs ValidateArgs (guard wrapper → original validator) before RunE, so resolveContentValue is unreachable.

3. </dev/tty — removed.

Right; it is wrong on Windows and on headless runners with no controlling terminal. The hint is now For a literal "-" flag value, run the command without piped stdin, and SKILL.md matches. Kept the shape of the remedy, dropped the platform-specific spelling.

One adjacent fix, flag it if you want it split out. Adding the arity bound surfaced that cobra's arity errors were classified api_error (exit 7):

$ printf body | basecamp messages create Title - unexpected --json
{"ok": false, "error": "accepts at most 2 arg(s), received 3", "code": "api_error"}

That tells an agent to retry a call that can never succeed — the opposite of what this PR is for. transformCobraError already rewrites the received 0 case to a usage error; I extended it to the rest of the arity family, keeping cobra's wording (already clear) and fixing only the code. Now usage / exit 1. This is pre-existing and affects other commands too (chat post a b had the same envelope), so it is a behavior change beyond the stated scope — say the word and I will lift it into its own PR.

bin/ci green.

On the Go bump: agreed it is unrelated to this feature and belongs on main, not here. Worth knowing before someone attempts it as a one-liner: everything derives from go-version-file: go.mod, so the pin itself is one line — but the nix-build job exists precisely to catch a go.mod bump outpacing flake.lock (see the comment at test.yml:481, written after #533 did exactly that), so the change is go.mod + a nixpkgs carrying 1.26.6 + make update-nix-hash. Not mine to land from this branch; I will open it separately on request.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (3)

internal/commands/chat.go:790

  • The update path has the same silent stdin loss: printf intended | basecamp chat update 123 literal --content - chooses the positional value and never consumes the explicitly requested stdin input. Reject simultaneous positional content and --content before choosing the source.
			if len(args) > 1 {
				messageContent = args[1]
				argIndex, what = 1, "[content]"

internal/commands/chat.go:327

  • When a positional message and --content - are both provided, this branch silently wins and the explicit stdin source is never read. For example, printf intended | basecamp chat post literal --content - passes the guard (there is only one -) but posts literal, discarding the pipe. Reject the two content sources together before selecting one.

This issue also appears on line 788 of the same file.

			if len(args) > 0 {
				messageContent = args[0]
				argIndex, what = 0, "<message>"

internal/commands/stdin.go:196

  • Deduplicating shared flag values here can misname the offending alias because VisitAll is alphabetical, not invocation order. For example, --in old --project - leaves both aliases changed with the shared value -, but --in is visited first and reported even though --project carried the dash. Preserve/report the changed alias group so the diagnostic does not identify the wrong flag.
	// Alias flags (--description/--desc) share one backing value, and pflag
	// hands each alias the same Value instance — dedupe on it, or a value set
	// through both spellings would count as two stdin inputs.
	seen := map[pflag.Value]bool{}
	cmd.Flags().VisitAll(func(f *pflag.Flag) {
		if !f.Changed || seen[f.Value] {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8f09d7a7d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/gauges.go Outdated
Comment thread skills/basecamp/SKILL.md Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 20:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42b536095f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/stdin.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.

Comment thread internal/commands/stdin.go Outdated
…docs path

- Resolver.IsInteractive now requires stdin to be a character device too:
  a Bubble Tea picker reads keystrokes from stdin, so piped stdin can never
  drive one — and when a command is consuming piped content (a "-" stdin
  input), a picker would eat that content as key events. This closes the
  ordering hazard for every picker at the mechanism rather than reordering
  each stdin-enabled RunE (gauges create, docs, cards, schedule, templates).
- chat post/update reject a positional message combined with --content
  instead of the positional silently winning; with "-" in play the losing
  source would discard piped content unread.
- SKILL.md and the docs-create example referenced 'docs create', which does
  not exist; the registered path is 'docs documents create'.
Copilot AI review requested due to automatic review settings August 19, 2026 21:10
@github-actions github-actions Bot added the tui Terminal UI label Aug 19, 2026
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the body-level (suppressed) findings from the Copilot review rounds in 9310135:

  • chat post/update dual content sources — fixed: a positional message combined with --content is now a usage error instead of the positional silently winning; with - in play the losing source would have discarded piped content unread. Unit-tested. (The messages/cards/docs extra-positional arity findings were already fixed in b8f09d7's bounded create arity.)
  • Alias diagnostic may name the other spelling — not doing this: when both spellings of one aliased flag carry the dash, the guard names whichever alias VisitAll reaches first. Both names point at the same logical input the user just typed, so the diagnostic still identifies the right thing to fix; tracking invocation order through pflag to fix a cosmetic corner isn't worth the machinery.

Also in this round: pickers are now gated off when stdin is piped (mechanism fix for the read-ordering finding — details in that thread), and the docs create references now use the registered docs documents create path.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (4)

internal/commands/cards.go:1119

  • cards update accepts --attach, but unlike the create path it resolves and drains --body - before checking whether attachment paths are readable. Thus producer | basecamp cards update 1 --body - --attach /missing waits for/consumes the producer and only then reports the already-known file error, contrary to the fail-before-stdin behavior added for attachment-bearing commands. Validate attachFiles before resolving the body.
			content, err := resolveContentValue(cmd, content, -1, "--body")

internal/commands/chat.go:830

  • The target line is not validated until after this stdin read (the ParseInt/URL checks are at lines 843–925). Consequently, producer | basecamp chat update nope - drains the entire producer before returning Invalid line ID; malformed or foreign URLs do the same. Hoist the locally decidable line-reference validation ahead of resolveContentValue, as the other stdin-enabled update commands do.
			case "", "text/html", "text/plain":

skills/basecamp/SKILL.md:119

  • This claim is broader than the implementation. For example, printf x | basecamp todos create --json returns missingArg, whose structured hint is only the usage line; check-in answers and boost have the same behavior. Only comments create and notes set explicitly detect a bare pipe and teach -, so agents following this skill will not receive the promised guidance elsewhere. Narrow the statement to those commands (or add the hint consistently).
    internal/commands/todos.go:1584
  • Due/start-date validation still occurs after this stdin read at lines 1606–1618. An invocation such as producer | basecamp todos update 1 --description - --due nonsense therefore drains the producer before returning the deterministic invalid-date error. Parse and validate both date flags before resolving --description -, while retaining the parsed values for the request.

Copilot AI review requested due to automatic review settings August 20, 2026 09:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/commands/cards.go:1116

  • Validate attachFiles before resolving --body. cards update 1 --body - --attach /missing currently drains (and can block on) stdin, then fails in uploadAttachments on a path that was already known to be unreadable. This is the one attachment-capable stdin command missing the new pre-read validation.
			content, err := resolveContentValue(cmd, content, -1, "--body")

internal/commands/chat.go:836

  • Validate the target before reading stdin. The existing line-reference checks remain below this resolver, so chat update nope - --room 1 drains the producer before returning Invalid line ID; invalid or untrusted chat-line URLs do the same. Move the local URL/line-ID validation ahead of resolveContentValue, as the other stdin-enabled updates do.
			var contentErr error
			messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e4d545d200

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/chat.go Outdated
Comment thread internal/commands/comment.go
Comment thread internal/commands/messages.go
Comment thread internal/commands/files.go
Comment thread internal/commands/files.go
Copilot AI review requested due to automatic review settings August 20, 2026 09:25
The fallback writer was not the path that retried — app.Err was. When a
filter emits results and then fails, writeJQ has already written, and
falling through to the plain writer appended a second, unfiltered
envelope. 'todos create --jq ".error, error(\"stop\")"' printed both a
bare line and a full JSON document to stdout, which is two incompatible
outputs for the machine consumer the filter exists to serve.

Once a jq-backed write has begun, stdout is final: report the render
failure on stderr and exit with the original code. Without a filter
nothing partial can have been written through one, so the plain fallback
stays as the last resort for a broken pipe.

jqUsable stays — it covers the other path, where no app existed yet — but
it was never able to prevent this, and the comment that implied otherwise
now describes only the path it governs.

The old unit test drove two writers directly rather than Execute, so it
could not have caught this. Replaced with a predicate test, plus an e2e
case that runs the real binary and asserts stdout holds exactly one
document; removing the fix makes it fail.
@jeremy

jeremy commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Fixed in 27b8615. You were right, and I had fixed the wrong path.

The defect. I patched the fallback writer at the bottom of Execute and reasoned about it as though it were the only place a second write could happen. It isn't: the retry is in app.Err, above it. jqUsable was never able to prevent this — as you say, it only answers what is knowable before output exists — and my comment asserting the property was describing a path that wasn't the one at fault. Reproduced exactly as reported.

The fix, taking the invariant you stated: once a jq-backed write has begun, stdout is final. When app.Err fails and a filter was in play, the render failure goes to stderr and the process exits with the original code — no replay. Without a filter nothing partial can have been written through one, so the plain fallback stays as the last resort for a broken pipe.

$ basecamp todos create --jq '.error, error("stop")' 2>/dev/null
<content> required                                  # one document, exit 1

$ basecamp todos create --jq '.error, error("stop")' 2>&1 >/dev/null
error rendering error output through --jq: jq filter error: error: stop

I did not buffer. Your point that TTY state can be captured separately is fair and it is a workable design, but it means threading the destination's TTY-ness through output.Options so the sanitizer keeps working — a change to the output package's contract to fix a control-flow bug in Execute. Not replaying is the smaller correct change, and it is the invariant you named as the minimum. Say the word if you want atomic stdout instead and I will do the plumbing.

On the test. Your criticism was exact — it drove two writers directly and never reached Execute, so it asserted something true about the output package and nothing about the decision under review. Replaced with a predicate test for jqUsable (which is honestly all a unit test can cover here) plus an e2e case running the real binary that asserts stdout holds exactly one line. I verified it catches the regression: with the fix removed it fails on the line count, and passes with it restored.

jqUsable stays — it covers the other path, where no app exists yet because the error was raised before one was built — but its comment now describes only that path rather than implying a global property.

Scope note. The 110 uncommitted lines you saw were mid-round work; they landed as ac3f38e and e4d545d (the remaining pre-read validations, --attach paths across all eight commands, and two incomplete fixes of mine from the round before). Your verdict predates them, so they are unreviewed.

And agreed on io.ReadAll — it remains unbounded regardless of ordering, and a cap is a policy call rather than a review fix. Left for a separate decision.

bin/ci green, e2e now 11 cases.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/cli/root.go:467

  • A valid filter can still fail at runtime on this no-app fallback path (for example, the dash guard can fail before app creation and .error, error("stop") writes once, then errors). The writer.Err result below is discarded, so unlike the app-backed path, that filter failure is never reported on stderr. Capture it and emit the same stderr diagnostic without retrying stdout.
		if jqFilter != "" && !jqUsable(jqFilter) {
			jqFilter = ""
		}

skills/basecamp/SKILL.md:119

  • This parenthetical overstates current behavior. Most required-content paths still use the generic missingArg/Cobra error; for example, piped todos create --json reports only <content> required plus usage and does not mention -. Only the explicitly updated comments/notes paths provide that hint, so either add the hint consistently or remove this claim.

Copilot AI review requested due to automatic review settings August 20, 2026 09:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27b86152f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/cards.go
Comment thread internal/commands/todolists.go
Comment thread internal/commands/files.go
Comment thread internal/commands/cards.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

internal/commands/chat.go:839

  • The target is still validated only after this stdin read (and after account/project resolution at lines 845-929). Thus producer | basecamp chat update nope - drains or can block forever on the producer before returning Invalid line ID, contrary to the PR's local-validation-before-stdin contract. Validate a bare line ID—and the host/type/collection shape for URL targets—before resolving the content; retain only account-dependent URL checks later.
			var contentErr error
			messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what)
			if contentErr != nil {
				return contentErr
			}

Comment thread internal/cli/root.go Outdated
The AST check compared the first recognized check with the first read, so
one early check hid every later one — which is why the sweep kept looking
finished. It now flags every recognized check that follows the read, and
found twelve more sites immediately.

Dropped dateparse.Parse and isNumericID from the recognized set: neither
can fail, so they decide nothing and only report branch selection, which
would be answered with suppressions rather than fixes.

What it found: duplicate schedule timestamp and card column checks left
behind by earlier hoists, chat update's URL host and shape plus its line
and room IDs, and files replace's recording-type check. For the URL cases
only the account comparison genuinely needs a resolved account, so that
is all that still follows the read.

Semantic cases the AST cannot see, from the same review: cards update
never validated its attachments (the earlier sweep took one RunE per
file), schedule create discarded its ParseInt error and would have
created under schedule 0, and explicitly supplied dock IDs were parsed
only once a request was being built. getDockToolID returns an explicit
value verbatim, so requireNumericID settles them from the flag alone.

uploadAttachments now calls validateAttachPaths rather than duplicating
it, which is what its comment already claimed.
Copilot AI review requested due to automatic review settings August 20, 2026 10:18
@jeremy

jeremy commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Fixed in 58ff6da. The instrument criticism was the important one and it was correct.

The backstop was comparing first-to-first. min(check) < min(read) means a single early check certifies the whole function, which is exactly why each round kept looking finished and then wasn't. It now flags every recognized check that follows the read. Re-running it immediately produced twelve violations across six commands — the sweep had never been close to complete, and the tool had been telling me it was.

I also removed two names from the recognized set rather than expanding blindly: dateparse.Parse and isNumericID cannot fail, so they decide nothing on their own. Listing them reports branch selection as if it were validation, and that noise gets answered with suppressions instead of fixes.

What the fixed check found, all now hoisted: duplicate schedule-timestamp and card-column checks left behind by my earlier hoists; chat update's URL host and shape plus its line and room IDs; files replace's recording-type check. For both URL cases only the account comparison genuinely needs a resolved account, so that alone still follows the read — the host and shape checks need just the configured base URL.

The semantic cases the AST cannot see, all confirmed and fixed:

  • cards update never validated attachments. My earlier --attach sweep took one RunE per file, so the second one in cards.go was skipped — the same first-match blindness in the script I used to find the sites.
  • schedule create discarded its ParseInt error and would have created under schedule 0. The create-side twin, as you said.
  • Explicit dock IDs were parsed only once a request was being built. getDockToolID returns an explicit value verbatim — there is no name resolution for these — so requireNumericID settles them from the flag alone. Applied to --schedule, --message-board, --folder, --todoset, and --card-table at the stdin-reading commands.

Eleven of these are pinned in the tracking-reader table (now 34 cases), which asserts stdin is left unread and no request is issued.

Factual correction accepteduploadAttachments duplicated the validation rather than calling validateAttachPaths, which is what its comment claimed. It calls it now.

One disclosure: this commit also carries a change I did not author. A concurrent edit in the worktree — terminal-injection sanitization for the stderr diagnostic I added last round, via jqRenderErrorDiagnostic — was swept in by a git add -A. It is a real hardening of my change (a jq runtime error can carry filter-selected response data), it is tested, and CI is green, but it is described by neither my commit message nor this comment's summary. Happy to split it into its own commit with a proper message on request.

bin/ci green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/commands/helpers.go:482

  • The existing applySubscribeFlags contract now precedes rejectSubscribeConflict, so Go associates all of it with the narrower helper even though that helper does not resolve subscriptions or return the documented pointer values. Keep the conflict helper's comment with it, then restore the contract directly above applySubscribeFlags.
// rejectSubscribeConflict answers the one part of applySubscribeFlags that
// needs neither the network nor an account, so callers that read stdin can
// settle it first: draining a pipe for an invocation this rejects makes the
// caller wait on a producer whose output is discarded, and lets a blank pipe
// answer "stdin is empty" instead of naming the conflict.

internal/commands/helpers.go:155

  • This block is attached to requireNumericID, but it starts by documenting getDockToolID; as a result the new helper has a misleading doc comment and getDockToolID loses the documentation immediately above it. Move the helper and its comment before the existing getDockToolID block.

This issue also appears on line 478 of the same file.

// requireNumericID rejects an explicitly supplied dock or container ID that is
// not numeric. getDockToolID returns an explicit value verbatim — there is no
// name resolution for these — so the check needs neither an account nor the
// network, and commands that also read a "-" input run it before the read
// rather than discovering it after a request is already being built.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58ff6da8df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/cards.go
uploads create and the top-level upload shortcut read --description -
before rejecting a malformed --folder, so a doomed invocation drained the
producer first. Both now validate it before the read.

Two backstop corrections, without which that class stays invisible:
requireNumericID was not in the recognized set, so moving one after a
read reported nothing — verified by doing exactly that, which now fails
the check. And urlarg.IsURL is out: like isNumericID it only selects a
branch and cannot reject an invocation, so listing it reports branch
selection as validation.

requireNumericID was also sitting inside getDockToolID's doc comment,
orphaning half of it. Moved above the block.
Copilot AI review requested due to automatic review settings August 21, 2026 00:58
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Fixed in a6bcf29. Not splitting 58ff6da — agreed, and the disclosure is now in the PR description under "Commit note" so it survives the comment thread scrolling past.

The upload hole, both constructors. uploads create and the top-level upload shortcut both validate --folder before the read now. Tracking-reader cases added for each, and I checked they bite: removing either check fails its case.

Both backstop corrections, and the second one matters more than it looks.

requireNumericID is now recognized. Your framing was exactly right — without it, moving one after a read reports nothing, which is a worse failure than the original bug because the check still appears to exist. I verified by doing precisely that rather than by deleting the call:

files.go: reads stdin at files.go:894:23 but checks its arguments at files.go:898:14
          — hoist the check above the resolver

urlarg.IsURL removed. Same reasoning as isNumericID: it selects a branch and cannot reject anything, so listing it reports branch selection as validation. That is the noise that gets answered with suppressions rather than fixes, which is how a backstop stops being one.

The doc commentrequireNumericID had been inserted between getDockToolID's doc block and its signature, orphaning the second half onto the wrong function. Moved above the block.

One note on your bin/ci run. The missing shim is not specific to actionlint: shellcheck and pwsh have the same problem in this environment, and the pwsh one is the interesting case because it fails as three test failures in installer.bats rather than as a missing-tool error. I hit it on this run and confirmed it reproduces on a stashed tree before assuming it was environmental. Prepending the install dirs gets a true green:

$HOME/.local/share/mise/installs/actionlint/1.7.12
$HOME/.local/share/mise/installs/shellcheck/0.11.0/shellcheck-v0.11.0
$HOME/.local/share/mise/installs/powershell/7.6.5

Full bin/ci green with those on PATH — 36 tracking-reader cases, all e2e.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/cli/root.go:859

  • The new self-documenting agent-help behavior is not covered by a test. Existing agent-help tests only assert argument metadata, so dropping this call, misrendering an arg:N+ token, or accidentally advertising the --out - stdout exemption as stdin would still pass. Please add focused agent-help assertions for a command with both positional and flag stdin inputs and for a download command whose --out annotation must be omitted.
	if note := stdinDashNote(cmd, info.Args); note != "" {
		info.Notes = append(info.Notes, note)

@jeremy
jeremy merged commit 8ce0854 into main Aug 21, 2026
26 checks passed
@jeremy
jeremy deleted the stdin branch August 21, 2026 07:22
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Note for anyone arriving from a notification: this PR was squash-merged as 8ce0854 with eleven review threads still open. All eleven are now answered and resolved.

Eight described code that later commits on this branch had already changed — they were reviewing a snapshot from earlier in the round. I verified each against the merged tree rather than assuming, and each is pinned in the tracking-reader table so it cannot regress silently.

Three were genuinely still live and are fixed in #645:

  • comments create with an all-invalid target argument
  • an explicitly blank --subscribe ""
  • cards update --due accepting an unparseable date

Two more orderings that were already correct but unpinned — chat update --room and cards create --card-table — got tracking-reader cases there too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations skills Agent skills tests Tests (unit and e2e) tui Terminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants